昨天完成了 MCP Bridge。
Host 已經知道有哪些 Tool 可以交給模型,也加上了工具與檔案範圍的限制。
但 Tool 到底什麼時候該用,還是我們自己寫死的:
await tools.call(
"read_file",
{"path": "pyproject.toml"},
)
今天往前走一步。
不再替模型決定要用 read_file,也不先幫它填好參數,而是把 Tool 的名稱、說明和 Schema 交給模型,讓它自己選一次。
今天只跑一輪。
先確認「模型會不會做出正確的工具決策」,明天再把它接成完整迴圈。
ReAct 的核心是讓 推理與行動交錯進行。
模型先根據目前資訊決定下一步,需要外部資料時就執行 Action,取得 Observation,再根據新資訊決定後續動作。
放到現在的 devbench,可以理解成:
問題
↓
模型判斷下一步
↓
Action:呼叫 Tool
↓
Observation:Tool 真正回傳的結果
↓
下一輪決策
例如:
flowchart LR
Q[使用者問題] --> M[模型決定下一步]
M --> A[Action:read_file]
A --> O[Observation:檔案內容]
O --> N[下一輪決策]
這裡最重要的是:
Action 和 Observation 都是可以驗證的。
我們可以知道模型:
選了哪個 Tool
帶了哪些參數
Tool 有沒有真的執行
最後回傳成功還是失敗
至於模型內部怎麼推理,不是我們拿來驗證程式是否正確的依據。
真正能查核的是它做了什麼,以及工具實際回了什麼。
ReAct 原始論文討論的也是推理與外部行動互相補充的流程,而不是單純要求模型把「思考過程」印得更長。
昨天的 MCP Bridge 已經把 Server 提供的 Tool 轉成 ToolSpec。
所以今天不用再另外維護一份:
TOOLS = [
{
"name": "read_file",
...
}
]
而是直接使用:
tools.specs
接著把它交給 OllamaClient:
response = await llm.chat(
[
ChatMessage(
role="system",
content=SYSTEM,
),
ChatMessage(
role="user",
content=DEFAULT_TASK,
),
],
tools=tools.specs,
)
模型看到的資訊主要就是:
Tool 名稱
Tool 說明
參數名稱
JSON Schema
所以 Tool 的命名和描述其實很重要。
例如:
read_file
就比:
do_action
清楚。
同樣地:
path
也比:
input
更容易讓模型知道參數該放什麼。
模型並不知道我們腦中那份「這個 Tool 應該怎麼用」的隱藏規格。
它能判斷的,就是我們實際交給它的 Schema 和描述。
今天的程式很短:
import asyncio
from ironman.agent import DEFAULT_TASK, SYSTEM
from ironman.llm import OllamaClient
from ironman.mcp_bridge import devbench
from ironman.models import ChatMessage
async def main() -> None:
async with devbench() as tools, OllamaClient() as llm:
response = await llm.chat(
[
ChatMessage(
role="system",
content=SYSTEM,
),
ChatMessage(
role="user",
content=DEFAULT_TASK,
),
],
tools=tools.specs,
)
if not response.message.tool_calls:
raise RuntimeError(
"本次模型沒有選擇工具,不能算通過工具決策實驗"
)
for call in response.message.tool_calls[:2]:
observation = await tools.call(
call.name,
call.arguments,
)
assert not observation.is_error
if __name__ == "__main__":
asyncio.run(main())
跟昨天最大的差別只有一個:
昨天是我們決定:
tools.call("read_file", ...)
今天變成:
模型決定 Tool
↓
Host 取得 tool_calls
↓
Host 檢查並執行
模型並沒有直接取得 MCP Server 的控制權。
真正執行工具的還是 Host。
假設模型回傳:
read_file(
path="src/ironman/config.py"
)
Host 執行後取得:
Observation:
config.py 的實際內容
今天就停在這裡。
還沒有把 Observation 再送回模型。
所以這不是一個完整的 Agent Loop。
今天只驗證:
問題
↓
模型選 Tool
↓
Host 執行
↓
取得 Observation
而不是:
問題
↓
Tool
↓
Observation
↓
再思考
↓
再用 Tool
↓
...
↓
最後答案
刻意拆開的原因很簡單。
如果第一輪 Tool selection 都還不穩,就直接進完整迴圈,出錯時會很難判斷問題到底在哪裡。
今天先確認模型「會選」。
明天再讓它「會繼續走」。
假設模型產生:
我要讀取 config.py
這還不算成功。
真正要看的,是:
Action
read_file(path="src/ironman/config.py")
↓
Observation
is_error = False
實際取得檔案內容
模型要求呼叫 Tool,只代表它提出一個 Action。
Tool 有沒有真的成功,要看 Observation。
所以今天的程式才會寫:
assert not observation.is_error
而不是只確認:
response.message.tool_calls
這個差別之後會一直出現:
模型提出行動,不等於行動已成功。
這次只在設定:
IRONMAN_LIVE=1
時真的連到本機 Ollama。
沒有設定時,涉及模型的測試會標記為:
skipped
這件事也要分清楚:
passed → 實際執行,而且符合預期
failed → 實際執行,但結果不符預期
skipped → 根本沒有執行這項測試
所以 skipped 不能拿來證明:
模型真的會選對 Tool。
要驗證模型行為,還是得跑一次 Live Test。
🧪 隔離環境實測

看結果時,我會先看兩件事:
Action 對不對?
Observation 有沒有成功?
這一輪成立,才有資格進下一步。
明天要把 Observation 再交回模型。
這時候會遇到另一個問題:
Tool 到底應該回多少資訊?
例如只回:
執行失敗
資訊太少。
模型不知道到底是:
檔案不存在
參數格式錯誤
權限不足
還是 Server 出錯
下一輪就可能又做一次幾乎一樣的呼叫。
但另一個極端也不好。
如果直接把完整 stack trace、內部路徑甚至敏感資訊全部塞回去,不只浪費 Context,也可能造成資訊外洩。
所以比較理想的是:
正常結果
→ 回傳完成下一步需要的資料
預期中的錯誤
→ 給模型足夠修正的提示
權限或安全拒絕
→ 明確拒絕,但不洩漏內部資訊
Observation 不只是 Tool 的輸出。
它會直接變成模型下一輪判斷的依據。
昨天,我們完成了:
模型
↓
Host
↓
受限的 MCP Bridge
↓
devbench
今天補上第一個真正的模型決策:
問題
↓
模型選 Tool
↓
Action
↓
Observation
但目前還只走了一步。
真正的 ReAct 還需要把 Observation 放回對話,讓模型根據結果繼續決定:
下一步還要不要使用工具?
什麼時候已經有足夠資訊?
什麼時候該停止?
而一旦模型可以自己一直走,就一定要先回答另一個問題:
誰負責叫它停?
明天進入 Day 13:
用 Python 實作 ReAct:完成第一個會使用 MCP 的 Agent。